| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051 |
- 'use client';
- import { useState, useEffect, useCallback, useRef } from 'react';
- import { useParams, useRouter } from 'next/navigation';
- import { useAuth } from '@/lib/auth-context';
- import { assetsApi, commentsApi, AssetWithComments, Comment, AnnotationData, TranscodeStatus } from '@/lib/api';
- import { Avatar } from '@/components/ui/avatar';
- import { VideoPlayer } from '@/components/video-player/VideoPlayer';
- import { Tool } from '@/components/video-player/AnnotationCanvas';
- const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
- const MAX_ANNOTATIONS = 10;
- const STATUS_CONFIG: Record<string, { label: string; colorClass: string; bgClass: string; dotClass: string }> = {
- PENDING_REVIEW: { label: 'Pending Review', colorClass: 'text-warning', bgClass: 'badge-warning', dotClass: 'status-dot-pending' },
- CHANGES_REQUESTED: { label: 'Changes Requested', colorClass: 'text-warning', bgClass: 'badge-warning', dotClass: 'status-dot-changes' },
- APPROVED: { label: 'Approved', colorClass: 'text-success', bgClass: 'badge-success', dotClass: 'status-dot-approved' },
- REJECTED: { label: 'Rejected', colorClass: 'text-danger', bgClass: 'badge-danger', dotClass: 'status-dot-rejected' },
- };
- const TRANSCODE_CONFIG: Record<TranscodeStatus, { label: string; color: string; bg: string; spinner: boolean }> = {
- PENDING: { label: 'Queued', color: '#94A3B8', bg: 'rgba(148,163,184,0.08)', spinner: false },
- UPLOADING: { label: 'Uploading video…', color: '#60A5FA', bg: 'rgba(96,165,250,0.08)', spinner: true },
- PROCESSING: { label: 'Transcoding…', color: '#A78BFA', bg: 'rgba(167,139,250,0.08)', spinner: true },
- COMPLETED: { label: 'Ready', color: '#34D399', bg: 'rgba(52,211,153,0.08)', spinner: false },
- FAILED: { label: 'Transcode failed', color: '#F87171', bg: 'rgba(248,113,113,0.08)', spinner: false },
- UNSUPPORTED_CODEC: { label: 'Unsupported codec', color: '#FBBF24', bg: 'rgba(251,191,36,0.08)', spinner: false },
- };
- function formatTimecode(seconds: number, fps: number = 30): string {
- if (!seconds || isNaN(seconds)) return '00:00:00:00';
- const h = Math.floor(seconds / 3600);
- const m = Math.floor((seconds % 3600) / 60);
- const s = Math.floor(seconds % 60);
- const f = Math.round(seconds * fps) % fps;
- return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}:${String(f).padStart(2,'0')}`;
- }
- export default function ReviewPage() {
- const params = useParams();
- const assetId = params.assetId as string;
- const { token, user } = useAuth();
- const router = useRouter();
- const [asset, setAsset] = useState<AssetWithComments | null>(null);
- const [comments, setComments] = useState<Comment[]>([]);
- const [loading, setLoading] = useState(true);
- const [currentTime, setCurrentTime] = useState(0);
- const [panelWidth, setPanelWidth] = useState(380);
- const [showApproval, setShowApproval] = useState(false);
- const [updatingStatus, setUpdatingStatus] = useState(false);
- const [newComment, setNewComment] = useState('');
- const [submitting, setSubmitting] = useState(false);
- const [replyTo, setReplyTo] = useState<Comment | null>(null);
- const [showResolved, setShowResolved] = useState(false);
- // Drawing state — lifted to page level
- const [drawMode, setDrawMode] = useState(false);
- const [drawTool, setDrawTool] = useState<Tool>('arrow');
- const [drawColor, setDrawColor] = useState('#ef4444');
- const [pendingStrokes, setPendingStrokes] = useState<AnnotationData[]>([]);
- // The comment we're annotating (null = annotating the main video, not a specific comment)
- const [annotatingComment, setAnnotatingComment] = useState<Comment | null>(null);
- // Portrait / landscape detection
- const [isPortrait, setIsPortrait] = useState(false);
- useEffect(() => {
- const mq = window.matchMedia('(orientation: portrait)');
- setIsPortrait(mq.matches);
- const handler = (e: MediaQueryListEvent) => setIsPortrait(e.matches);
- mq.addEventListener('change', handler);
- return () => mq.removeEventListener('change', handler);
- }, []);
- const isDraggingRef = useRef(false);
- const panelRef = useRef<HTMLDivElement>(null);
- const resizeStartRef = useRef<{ x: number; w: number } | null>(null);
- // Ref to capture strokes for save callback (avoids closure stale value)
- const pendingStrokesRef = useRef<AnnotationData[]>([]);
- const annotatingCommentRef = useRef<Comment | null>(null);
- // Keep refs in sync with state
- useEffect(() => { pendingStrokesRef.current = pendingStrokes; }, [pendingStrokes]);
- useEffect(() => { annotatingCommentRef.current = annotatingComment; }, [annotatingComment]);
- const fps = asset?.fps ?? 30;
- // Derive the current user's project role
- const currentUserRole = asset?.project.members.find(m => m.user.id === user?.id)?.role;
- const isProjectAdmin = currentUserRole === 'ADMIN';
- const canComment: boolean | undefined = !!(currentUserRole && currentUserRole !== 'VIEWER');
- // ── Poll for transcode progress ───────────────────────────────────────────
- const isTranscoding = asset?.transcodeStatus === 'COMPLETED';
- const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
- useEffect(() => {
- if (isTranscoding) {
- if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
- return;
- }
- if (pollRef.current) return;
- pollRef.current = setInterval(async () => {
- if (!token) return;
- try {
- const { asset: updated } = await assetsApi.getStatus(token, assetId);
- setAsset(prev => prev ? { ...prev, ...updated } : prev);
- } catch {}
- }, 2000);
- return () => { if (pollRef.current) clearInterval(pollRef.current); };
- }, [token, assetId, isTranscoding]);
- // Load asset + comments
- const loadData = useCallback(async () => {
- if (!token) return;
- try {
- const [{ asset: a }, { comments: c }] = await Promise.all([
- assetsApi.get(token, assetId),
- commentsApi.list(token, assetId),
- ]);
- setAsset(a);
- setComments(c);
- } catch {
- router.push('/projects');
- } finally {
- setLoading(false);
- }
- }, [token, assetId, router]);
- useEffect(() => { loadData(); }, [loadData]);
- // ── Panel resize ─────────────────────────────────────────────────────────
- const handleMouseMove = useCallback((e: MouseEvent) => {
- if (!isDraggingRef.current || !resizeStartRef.current) return;
- const dx = e.clientX - resizeStartRef.current.x;
- setPanelWidth(Math.max(280, Math.min(600, resizeStartRef.current.w + dx)));
- }, []);
- const handleMouseUp = useCallback(() => {
- isDraggingRef.current = false;
- resizeStartRef.current = null;
- document.body.style.userSelect = '';
- document.body.style.cursor = '';
- }, []);
- useEffect(() => {
- window.addEventListener('mousemove', handleMouseMove);
- window.addEventListener('mouseup', handleMouseUp);
- return () => {
- window.removeEventListener('mousemove', handleMouseMove);
- window.removeEventListener('mouseup', handleMouseUp);
- };
- }, [handleMouseMove, handleMouseUp]);
- const handleResizeStart = (e: React.MouseEvent) => {
- e.preventDefault();
- isDraggingRef.current = true;
- resizeStartRef.current = { x: e.clientX, w: panelWidth };
- document.body.style.userSelect = 'none';
- document.body.style.cursor = 'col-resize';
- };
- // ── Comment actions ───────────────────────────────────────────────────────
- const handleAddComment = async (content: string, timestamp?: number, annotations?: AnnotationData[]) => {
- if (!token || !content.trim()) return;
- setSubmitting(true);
- try {
- const { comment } = await commentsApi.create(token, assetId, {
- content: content.trim(),
- timestamp,
- annotations,
- parentId: replyTo?.id,
- });
- if (replyTo) {
- setComments(prev => prev.map(c =>
- c.id === replyTo.id
- ? { ...c, replies: [...(c.replies ?? []), comment] }
- : c
- ));
- } else {
- setComments(prev => [...prev, comment]);
- }
- setNewComment('');
- setPendingStrokes([]);
- setReplyTo(null);
- } catch (err) {
- alert(err instanceof Error ? err.message : 'Failed to add comment');
- } finally {
- setSubmitting(false);
- }
- };
- const handleResolve = async (commentId: string, action: 'approve' | 'reject') => {
- if (!token) return;
- try {
- const { comment } = await commentsApi.resolve(token, commentId, action);
- setComments(prev => prev.map(c => c.id === commentId ? comment : c));
- } catch (err) {
- alert(err instanceof Error ? err.message : 'Failed to update comment');
- }
- };
- const handleRequestResolve = async (commentId: string) => {
- if (!token) return;
- try {
- const { comment } = await commentsApi.requestResolve(token, commentId);
- setComments(prev => prev.map(c => c.id === commentId ? comment : c));
- } catch (err) {
- alert(err instanceof Error ? err.message : 'Failed to request resolve');
- }
- };
- const handleDeleteComment = async (commentId: string) => {
- if (!token) return;
- if (!confirm('Delete this comment?')) return;
- try {
- await commentsApi.delete(token, commentId);
- setComments(prev => prev
- .filter(c => c.id !== commentId)
- .map(c => ({ ...c, replies: c.replies?.filter(r => r.id !== commentId) }))
- );
- } catch {
- alert('Failed to delete comment');
- }
- };
- // ── Annotation actions ─────────────────────────────────────────────────────
- // User clicks "Add annotation" on a comment — enter draw mode, annotate at current time
- const handleAddAnnotationClick = (comment: Comment) => {
- const existingCount = comment.annotations?.length ?? 0;
- if (existingCount >= MAX_ANNOTATIONS) {
- alert(`Maximum ${MAX_ANNOTATIONS} annotations per comment.`);
- return;
- }
- setPendingStrokes([]);
- setAnnotatingComment(comment);
- setDrawMode(true);
- };
- // Each completed stroke is added to pendingStrokes
- const handleStrokeComplete = (stroke: AnnotationData) => {
- setPendingStrokes(prev => {
- const next = [...prev, stroke];
- if (next.length >= MAX_ANNOTATIONS) {
- setDrawMode(false);
- }
- return next;
- });
- };
- // Save pending strokes as annotation on the parent comment (no separate reply)
- const handleSaveAnnotations = () => {
- const strokes = pendingStrokesRef.current;
- const parent = annotatingCommentRef.current;
- if (!token || !parent || strokes.length === 0) {
- setPendingStrokes([]);
- setDrawMode(false);
- setAnnotatingComment(null);
- return;
- }
- setSubmitting(true);
- setPendingStrokes([]);
- setDrawMode(false);
- setAnnotatingComment(null);
- commentsApi.updateAnnotations(token, parent.id, strokes).then(({ comment }) => {
- setComments(prev => prev.map(c => c.id === parent.id ? comment : c));
- }).catch(err => alert(err instanceof Error ? err.message : 'Failed to save annotation')).finally(() => setSubmitting(false));
- };
- // Discard pending strokes
- const handleUndoAnnotations = () => {
- setPendingStrokes([]);
- setDrawMode(false);
- setAnnotatingComment(null);
- };
- // Delete a single annotation from a comment (owner only)
- const handleDeleteAnnotation = async (commentId: string, remainingAnnotations: AnnotationData[]) => {
- if (!token) return;
- try {
- const { comment } = await commentsApi.updateAnnotations(token, commentId, remainingAnnotations);
- setComments(prev => prev.map(c => c.id === commentId ? comment : c));
- } catch {
- alert('Failed to delete annotation');
- }
- };
- const handleStatusUpdate = async (status: string) => {
- if (!token) return;
- setUpdatingStatus(true);
- try {
- const { asset: updated } = await assetsApi.updateStatus(token, assetId, status);
- setAsset(prev => prev ? { ...prev, status: updated.status } : prev);
- setShowApproval(false);
- } catch {
- alert('Failed to update status');
- } finally {
- setUpdatingStatus(false);
- }
- };
- const handleTimeUpdate = useCallback((time: number) => {
- setCurrentTime(time);
- }, []);
- const handleCommentSeek = useCallback((comment: Comment) => {
- const time = comment.timestamp ?? 0;
- setCurrentTime(time);
- const videoEl = document.querySelector('video') as HTMLVideoElement | null;
- if (videoEl) {
- videoEl.pause();
- videoEl.currentTime = time;
- }
- }, []);
- const status = asset?.status ?? 'PENDING_REVIEW';
- const statusCfg = STATUS_CONFIG[status];
- const transcodeCfg = asset ? TRANSCODE_CONFIG[asset.transcodeStatus] : null;
- const videoUrl = asset?.hlsPath
- ? `${API_BASE}/uploads${asset.hlsPath}`
- : asset
- ? `${API_BASE}/uploads/${asset.filePath}`
- : '';
- const allComments = comments.flatMap(c => [c, ...(c.replies ?? [])]);
- const visibleComments = showResolved ? comments : comments.filter(c => !c.resolved);
- // Only main comments (not replies) have annotations that should show on the video
- const visibleAnnotations = visibleComments.flatMap(c =>
- (c.annotations ?? []).map(ann => ({ annotation: ann, timestamp: c.timestamp ?? 0 }))
- );
- if (loading) {
- return (
- <div className="h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
- <div className="flex items-center gap-3" style={{ color: 'var(--text-muted)' }}>
- <div className="w-5 h-5 rounded-full animate-spin"
- style={{ borderColor: '#6366F1', borderTopColor: 'transparent' }} />
- <span className="text-sm">Loading review…</span>
- </div>
- </div>
- );
- }
- if (!asset) return null;
- return (
- <div className="h-screen flex flex-col overflow-hidden" style={{ background: 'var(--bg)' }}>
- {/* ── Top bar ──────────────────────────────────────────── */}
- <header className="h-12 flex items-center px-4 gap-3 shrink-0"
- style={{ background: 'rgba(10,11,20,0.95)', borderBottom: '1px solid rgba(255,255,255,0.06)', zIndex: 50 }}>
- <button
- onClick={() => router.push(`/projects/${asset.projectId}`)}
- className="flex items-center gap-1.5 text-xs transition-colors shrink-0"
- style={{ color: 'var(--text-muted)' }}
- >
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
- </svg>
- <span className="hidden sm:inline">Back</span>
- </button>
- <div className="w-px h-5 shrink-0" style={{ background: 'rgba(255,255,255,0.08)' }} />
- <div className="flex-1 min-w-0">
- <h1 className="text-xs font-medium truncate" style={{ color: 'var(--text)' }}>{asset.title}</h1>
- </div>
- <span className="text-xs hidden sm:inline shrink-0" style={{ color: 'var(--text-subtle)' }}>
- {asset.project?.name}
- </span>
- <div className="w-px h-5 shrink-0" style={{ background: 'rgba(255,255,255,0.08)' }} />
- {/* Download */}
- <a
- href={`${API_BASE}/uploads/${asset.filePath}`}
- download={asset.filename}
- className="flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-md transition-all shrink-0"
- style={{ color: '#60A5FA', background: 'rgba(96,165,250,0.08)' }}
- title="Download original video"
- >
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
- </svg>
- <span className="hidden sm:inline">Download</span>
- </a>
- <div className="w-px h-5 shrink-0" style={{ background: 'rgba(255,255,255,0.08)' }} />
- {/* Status selector */}
- <div className="relative shrink-0">
- <button
- onClick={() => setShowApproval(v => !v)}
- className="flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-md transition-all"
- style={{ background: statusCfg.bgClass.replace('badge-', 'rgba(').replace('warning', '245,158,11,0.15)').replace('success', '34,197,94,0.15)').replace('danger', '239,68,68,0.15)'), color: statusCfg.colorClass }}
- >
- <span className={`status-dot ${statusCfg.dotClass}`} />
- <span className="hidden sm:inline">{statusCfg.label}</span>
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
- </svg>
- </button>
- {showApproval && (
- <>
- <div className="fixed inset-0 z-40" onClick={() => setShowApproval(false)} />
- <div className="absolute right-0 top-full mt-2 z-50 rounded-xl overflow-hidden"
- style={{ background: '#1E2030', border: '1px solid rgba(255,255,255,0.10)', boxShadow: 'var(--shadow-panel)', minWidth: '200px' }}>
- {Object.entries(STATUS_CONFIG).map(([key, cfg]) => (
- <button
- key={key}
- onClick={() => handleStatusUpdate(key)}
- disabled={updatingStatus}
- className="w-full flex items-center gap-2.5 px-4 py-2.5 text-xs transition-colors hover:bg-white/5"
- style={{ color: key === status ? cfg.colorClass : 'var(--text)' }}
- >
- <span className={`status-dot ${cfg.dotClass}`} />
- <span className="flex-1 text-left">{cfg.label}</span>
- {key === status && (
- <svg className="w-3.5 h-3.5" style={{ color: '#6366F1' }} fill="currentColor" viewBox="0 0 20 20">
- <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
- </svg>
- )}
- </button>
- ))}
- </div>
- </>
- )}
- </div>
- </header>
- {/* ── Body ───────────────────────────────────────────── */}
- {/* Landscape: side-by-side | Portrait: stacked (video top, comments bottom) */}
- <div
- className="flex flex-1 overflow-hidden"
- style={isPortrait
- ? { flexDirection: 'column', overflowY: 'auto' }
- : { flexDirection: 'row' }}
- >
- {/* Video area */}
- <div
- className="overflow-y-auto p-3 sm:p-4 flex flex-col gap-3 min-w-0"
- style={isPortrait
- ? { flex: 'none', width: '100%', minHeight: '60vh' }
- : { flex: 1, overflowY: 'auto' }}
- >
- <VideoPlayer
- src={videoUrl}
- mimeType={asset.mimeType}
- fps={fps}
- comments={allComments}
- visibleAnnotations={visibleAnnotations}
- drawMode={drawMode}
- drawTool={drawTool}
- drawColor={drawColor}
- onDrawModeChange={setDrawMode}
- onDrawToolChange={setDrawTool}
- onDrawColorChange={setDrawColor}
- pendingStrokes={pendingStrokes}
- onStrokeComplete={handleStrokeComplete}
- onTimeUpdate={handleTimeUpdate}
- onCommentClick={handleCommentSeek}
- />
- {/* Transcode status overlay — shown when video is not ready */}
- {transcodeCfg && asset.transcodeStatus !== 'COMPLETED' && (
- <div className="mt-3 rounded-xl p-4 flex items-center gap-4"
- style={{ background: transcodeCfg.bg, border: `1px solid ${transcodeCfg.color}30` }}>
- {transcodeCfg.spinner ? (
- <div className="w-8 h-8 rounded-full animate-spin shrink-0"
- style={{ borderColor: transcodeCfg.color, borderTopColor: 'transparent', borderWidth: '2.5px' }} />
- ) : asset.transcodeStatus === 'FAILED' ? (
- <div className="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
- style={{ background: 'rgba(248,113,113,0.15)' }}>
- <svg className="w-4 h-4" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
- </svg>
- </div>
- ) : (
- <div className="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
- style={{ background: 'rgba(251,191,36,0.15)' }}>
- <svg className="w-4 h-4" style={{ color: '#FBBF24' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
- </svg>
- </div>
- )}
- <div className="flex-1 min-w-0">
- <div className="flex items-center gap-2 mb-1">
- <span className="text-sm font-medium" style={{ color: transcodeCfg.color }}>
- {transcodeCfg.label}
- </span>
- {asset.transcodeStatus === 'PROCESSING' && asset.transcodeProgress > 0 && (
- <span className="text-xs font-mono" style={{ color: transcodeCfg.color }}>
- {asset.transcodeProgress}%
- </span>
- )}
- </div>
- {asset.transcodeStatus === 'PROCESSING' && (
- <div className="w-full h-1 rounded-full overflow-hidden" style={{ background: 'rgba(255,255,255,0.08)' }}>
- <div
- className="h-full rounded-full transition-all duration-500"
- style={{ width: `${asset.transcodeProgress}%`, background: transcodeCfg.color }}
- />
- </div>
- )}
- {asset.transcodeStatus === 'FAILED' && asset.transcodeError && (
- <p className="text-xs mt-1" style={{ color: '#F87171' }}>
- {asset.transcodeError}
- </p>
- )}
- {asset.transcodeStatus === 'UNSUPPORTED_CODEC' && (
- <p className="text-xs mt-1" style={{ color: '#FB923C' }}>
- {asset.codec ? `Source codec "${asset.codec.toUpperCase()}" — will re-encode to H.264/AAC` : 'Re-encoding to browser-compatible format…'}
- </p>
- )}
- {asset.transcodeStatus === 'PROCESSING' && asset.codec && (
- <p className="text-xs mt-1" style={{ color: '#94A3B8' }}>
- Converting from {asset.codec.toUpperCase()} → H.264/AAC
- </p>
- )}
- {asset.transcodeStatus === 'UPLOADING' && (
- <p className="text-xs mt-1" style={{ color: '#94A3B8' }}>
- Video uploaded — queued for processing
- </p>
- )}
- </div>
- </div>
- )}
- {/* Keyboard shortcuts */}
- <div className="flex flex-wrap gap-3 text-xs shrink-0" style={{ color: 'var(--text-subtle)' }}>
- <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>Space</kbd> play/pause</span>
- <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>←</kbd><kbd className="px-1.5 py-0.5 rounded text-[10px] ml-0.5" style={{ background: 'rgba(255,255,255,0.06)' }}>→</kbd> seek ±5s</span>
- <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>U</kbd><kbd className="px-1.5 py-0.5 rounded text-[10px] ml-0.5" style={{ background: 'rgba(255,255,255,0.06)' }}>I</kbd> frame</span>
- <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>C</kbd> draw mode</span>
- <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>Esc</kbd> exit draw</span>
- <span className="font-mono text-[11px]">{formatTimecode(currentTime, fps)}</span>
- </div>
- </div>
- {/* Resize handle — only shown in landscape */}
- {!isPortrait && (
- <div className="resize-handle" onMouseDown={handleResizeStart} style={{ width: '4px' }} />
- )}
- {/* ── Comment panel ─────────────────────────────────── */}
- <div
- ref={panelRef}
- className="flex flex-col overflow-hidden shrink-0"
- style={isPortrait
- ? {
- flex: 1,
- width: '100%',
- minHeight: '40vh',
- background: 'rgba(10,11,20,0.98)',
- borderTop: '1px solid rgba(255,255,255,0.06)',
- }
- : {
- width: panelWidth,
- background: 'rgba(10,11,20,0.98)',
- borderLeft: '1px solid rgba(255,255,255,0.06)',
- }}
- >
- {/* Panel header */}
- <div className="px-4 py-3 flex items-center justify-between shrink-0"
- style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
- <div className="flex items-center gap-2">
- <h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>Comments</h2>
- <span className="text-xs px-1.5 py-0.5 rounded-full"
- style={{ background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' }}>
- {comments.length}
- </span>
- </div>
- <div className="flex items-center gap-2">
- <span className="font-mono text-xs" style={{ color: '#818CF8' }}>
- {formatTimecode(currentTime, fps)}
- </span>
- <button
- onClick={() => setShowResolved(v => !v)}
- className={`text-xs px-2 py-0.5 rounded-md transition-colors ${showResolved ? 'bg-indigo-600 text-white' : ''}`}
- style={!showResolved ? { background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' } : {}}
- >
- {showResolved ? 'Hide resolved' : 'Show resolved'}
- </button>
- </div>
- </div>
- {/* Drawing mode banner */}
- {drawMode && (
- <div className="px-4 py-2 shrink-0 flex items-center gap-2"
- style={{ background: 'rgba(59,130,246,0.12)', borderBottom: '1px solid rgba(59,130,246,0.2)' }}>
- <svg className="w-4 h-4 shrink-0" style={{ color: '#818CF8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
- </svg>
- <span className="text-xs flex-1" style={{ color: '#818CF8' }}>
- {annotatingComment
- ? `Drawing annotation on "${annotatingComment.user?.name}" — ${pendingStrokes.length}/${MAX_ANNOTATIONS} strokes`
- : `Drawing on video — ${pendingStrokes.length}/${MAX_ANNOTATIONS} strokes`}
- </span>
- <div className="flex items-center gap-1.5">
- <button
- onClick={handleUndoAnnotations}
- className="text-xs px-2 py-0.5 rounded transition-colors"
- style={{ background: 'rgba(239,68,68,0.15)', color: '#FCA5A5' }}
- >
- Undo all
- </button>
- <button
- onClick={handleSaveAnnotations}
- disabled={submitting || pendingStrokes.length === 0}
- className="text-xs px-2 py-0.5 rounded transition-colors disabled:opacity-40"
- style={{ background: 'rgba(34,197,94,0.15)', color: '#86EFAC' }}
- >
- {submitting ? 'Saving…' : 'Save'}
- </button>
- </div>
- </div>
- )}
- {/* Comment list */}
- <div className="flex-1 overflow-y-auto scroll-area">
- {visibleComments.length === 0 ? (
- <div className="flex flex-col items-center justify-center py-16 px-4 text-center">
- <div className="w-12 h-12 rounded-2xl flex items-center justify-center mb-3"
- style={{ background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.12)' }}>
- <svg className="w-6 h-6" style={{ color: '#6366F1' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
- </svg>
- </div>
- <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>No comments yet</p>
- <p className="text-xs leading-relaxed" style={{ color: 'var(--text-muted)' }}>
- Add a comment below or click <strong>Add annotation</strong> on an existing comment
- </p>
- </div>
- ) : (
- <div>
- {visibleComments.map(comment => (
- <CommentItem
- key={comment.id}
- comment={comment}
- currentUserId={user?.id ?? ''}
- fps={fps}
- canComment={canComment}
- isProjectAdmin={isProjectAdmin}
- onTimestampClick={handleCommentSeek}
- onReply={() => { setReplyTo(comment); }}
- onResolve={(action) => handleResolve(comment.id, action)}
- onRequestResolve={() => handleRequestResolve(comment.id)}
- onDeleteSelf={() => handleDeleteComment(comment.id)}
- onDelete={(id) => handleDeleteComment(id)}
- onAddAnnotation={() => handleAddAnnotationClick(comment)}
- onDeleteAnnotation={(anns) => handleDeleteAnnotation(comment.id, anns)}
- />
- ))}
- </div>
- )}
- </div>
- {/* New comment / reply input */}
- <div className="shrink-0 p-3"
- style={{ borderTop: '1px solid rgba(255,255,255,0.06)', background: 'rgba(10,11,20,0.80)' }}>
- {replyTo && (
- <div className="flex items-center gap-2 mb-2 text-xs" style={{ color: 'var(--text-muted)' }}>
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
- </svg>
- Replying to {replyTo.user?.name}
- <button onClick={() => setReplyTo(null)} className="ml-auto" style={{ color: 'var(--text-subtle)' }}>
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
- </svg>
- </button>
- </div>
- )}
- {/* Pending strokes indicator */}
- {pendingStrokes.length > 0 && (
- <div className="flex items-center gap-2 mb-2 text-xs" style={{ color: '#818CF8' }}>
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
- </svg>
- {pendingStrokes.length} stroke{pendingStrokes.length !== 1 ? 's' : ''} ready
- {annotatingComment ? ` → annotation on "${annotatingComment.user?.name}"` : ' → will be saved as new comment'}
- <button onClick={handleUndoAnnotations} className="ml-auto text-xs" style={{ color: '#FCA5A5' }}>Undo</button>
- </div>
- )}
- <form
- onSubmit={e => {
- e.preventDefault();
- if (newComment.trim() || pendingStrokes.length > 0) {
- handleAddComment(newComment, currentTime, pendingStrokes.length > 0 ? pendingStrokes : undefined);
- }
- }}
- className="flex gap-2"
- >
- <Avatar name={user?.name ?? 'U'} size="sm" />
- <div className="flex-1 flex gap-2">
- <textarea
- className="input flex-1"
- value={newComment}
- onChange={e => setNewComment(e.target.value)}
- placeholder={replyTo ? 'Write a reply…' : 'Add a comment…'}
- rows={1}
- style={{ resize: 'none', overflow: 'hidden' }}
- onKeyDown={e => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- if (newComment.trim() || pendingStrokes.length > 0) {
- handleAddComment(newComment, currentTime, pendingStrokes.length > 0 ? pendingStrokes : undefined);
- }
- }
- }}
- />
- <button
- type="submit"
- disabled={submitting || (!newComment.trim() && pendingStrokes.length === 0)}
- className="btn btn-primary btn-sm px-3"
- >
- {submitting ? (
- <div className="w-3.5 h-3.5 rounded-full animate-spin"
- style={{ borderColor: '#fff', borderTopColor: 'transparent' }} />
- ) : (
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 12h12M6 12l4-4M6 12l4 4" />
- </svg>
- )}
- </button>
- </div>
- </form>
- </div>
- </div>
- </div>
- </div>
- );
- }
- // ── CommentItem ─────────────────────────────────────────────────────────────
- function CommentItem({
- comment,
- currentUserId,
- fps,
- canComment,
- isProjectAdmin,
- onTimestampClick,
- onReply,
- onResolve,
- onRequestResolve,
- onDeleteSelf,
- onDelete,
- onAddAnnotation,
- onDeleteAnnotation,
- }: {
- comment: Comment;
- currentUserId: string;
- fps: number;
- canComment: boolean | undefined;
- isProjectAdmin: boolean;
- onTimestampClick: (c: Comment) => void;
- onReply: () => void;
- onResolve: (action: 'approve' | 'reject') => void;
- onRequestResolve: () => void;
- onDeleteSelf: () => void;
- onDelete: (id: string) => void;
- onAddAnnotation: () => void;
- onDeleteAnnotation: (annotations: AnnotationData[]) => void;
- }) {
- const isOwner = comment.userId === currentUserId;
- const isCommentAuthor = comment.userId === currentUserId;
- const name = comment.user?.name ?? 'Unknown';
- const isReply = !!comment.parentId;
- const annotations = comment.annotations ?? [];
- const canAddMore = annotations.length < MAX_ANNOTATIONS;
- // Resolve state machine
- const isResolved = comment.resolveStatus === 'RESOLVED';
- const isPending = comment.resolveStatus === 'PENDING_APPROVAL';
- const canApprove = isCommentAuthor || isProjectAdmin;
- const canRequest = canComment && !isResolved && !isPending && !isCommentAuthor;
- const canReopen = isResolved && canApprove;
- return (
- <div
- className="p-4 animate-fade-in"
- style={{ opacity: isResolved ? 0.55 : 1, paddingLeft: isReply ? '2.5rem' : undefined }}
- >
- <div className="flex gap-2.5">
- <Avatar name={name} size="sm" />
- <div className="flex-1 min-w-0">
- {/* Meta row */}
- <div className="flex items-center gap-2 mb-1 flex-wrap">
- <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>{name}</span>
- {comment.timestamp != null && (
- <button
- onClick={() => onTimestampClick(comment)}
- className="text-xs px-1.5 py-0.5 rounded font-mono transition-colors hover:bg-indigo-600/20"
- style={{ background: 'rgba(99,102,241,0.10)', color: '#818CF8', fontSize: '11px' }}
- >
- {formatTimecode(comment.timestamp, fps)}
- </button>
- )}
- {isPending && (
- <span className="text-xs px-1.5 py-0.5 rounded"
- style={{ background: 'rgba(251,191,36,0.12)', color: '#FCD34D' }}>
- Pending approval
- </span>
- )}
- {isResolved && (
- <span className="text-xs px-1.5 py-0.5 rounded"
- style={{ background: 'rgba(34,197,94,0.10)', color: '#86EFAC' }}>
- Approved
- </span>
- )}
- {isResolved && comment.resolvedBy && (
- <span className="text-xs" style={{ color: 'var(--text-subtle)' }}>
- by {comment.resolvedBy.name}
- </span>
- )}
- <span className="text-xs ml-auto" style={{ color: 'var(--text-subtle)' }}>
- {new Date(comment.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
- </span>
- </div>
- {/* Annotation preview badges */}
- {annotations.length > 0 && (
- <div className="flex flex-wrap gap-1 mb-2">
- {annotations.map((ann, i) => (
- <div
- key={i}
- className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded"
- style={{ background: `${ann.color}20`, color: ann.color, border: `1px solid ${ann.color}40` }}
- >
- <svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
- </svg>
- {ann.type}
- {isOwner && (
- <button
- onClick={() => {
- const remaining = annotations.filter((_, j) => j !== i);
- onDeleteAnnotation(remaining);
- }}
- className="ml-0.5 hover:opacity-70 transition-opacity"
- title="Delete this annotation"
- >
- <svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
- </svg>
- </button>
- )}
- </div>
- ))}
- </div>
- )}
- {/* Content */}
- <p className="text-sm leading-relaxed mb-2" style={{ color: 'var(--text-muted)' }}>
- {comment.content}
- </p>
- {/* Actions */}
- <div className="flex items-center gap-1">
- {!isReply && (
- <button
- onClick={onAddAnnotation}
- disabled={!canAddMore}
- className="text-xs px-2 py-1 rounded-md transition-colors disabled:opacity-30"
- style={{ color: '#818CF8' }}
- title={canAddMore ? `Add annotation (${annotations.length}/${MAX_ANNOTATIONS})` : `Max ${MAX_ANNOTATIONS} annotations reached`}
- >
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
- </svg>
- </button>
- )}
- {!isReply && (
- <button
- onClick={onReply}
- className="text-xs px-2 py-1 rounded-md transition-colors"
- style={{ color: 'var(--text-muted)' }}
- title="Reply"
- >
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
- </svg>
- </button>
- )}
- {/* Resolve / approval workflow buttons */}
- {!isReply && !isResolved && !isPending && (
- <>
- {canRequest ? (
- <button
- onClick={onRequestResolve}
- className="text-xs px-2 py-1 rounded-md transition-colors"
- style={{ color: '#6366F1' }}
- title="Request resolve approval"
- >
- <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
- </svg>
- Request resolve
- </button>
- ) : (
- <span
- className="text-xs px-2 py-1 opacity-30"
- style={{ color: '#6366F1' }}
- title={!canComment ? 'Viewers cannot request resolve' : isCommentAuthor ? 'Cannot resolve your own comment' : undefined}
- >
- <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
- </svg>
- Request resolve
- </span>
- )}
- </>
- )}
- {isPending && canApprove && !isReply && (
- <>
- <button
- onClick={() => onResolve('approve')}
- className="text-xs px-2 py-1 rounded-md transition-colors"
- style={{ color: '#86EFAC' }}
- title={`Approve (by ${comment.requestedBy?.name ?? 'someone'})`}
- >
- <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
- </svg>
- Approve
- </button>
- <button
- onClick={() => onResolve('reject')}
- className="text-xs px-2 py-1 rounded-md transition-colors"
- style={{ color: '#FCA5A5' }}
- title="Reject resolve request"
- >
- <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
- </svg>
- Reject
- </button>
- </>
- )}
- {isPending && !canApprove && !isReply && (
- <span className="text-xs px-2 py-1 opacity-40" style={{ color: '#FCD34D' }} title="Awaiting approval">
- <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
- </svg>
- Awaiting approval
- </span>
- )}
- {canReopen && !isReply && (
- <button
- onClick={() => onResolve('reject')}
- className="text-xs px-2 py-1 rounded-md transition-colors"
- style={{ color: '#86EFAC' }}
- title="Reopen comment"
- >
- <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
- </svg>
- Reopen
- </button>
- )}
- {isOwner && (
- <button
- onClick={onDeleteSelf}
- className="text-xs px-2 py-1 rounded-md transition-colors"
- style={{ color: 'var(--text-subtle)' }}
- title="Delete comment"
- >
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
- </svg>
- </button>
- )}
- </div>
- {/* Replies */}
- {comment.replies && comment.replies.length > 0 && (
- <div className="mt-3 space-y-3">
- {comment.replies.map(reply => (
- <ReplyItem
- key={reply.id}
- comment={reply}
- isOwner={reply.userId === currentUserId}
- onDelete={() => onDelete(reply.id)}
- />
- ))}
- </div>
- )}
- </div>
- </div>
- </div>
- );
- }
- // ── ReplyItem ──────────────────────────────────────────────────────────────
- // Replies have no resolve, no annotation, no timestamp — just content + delete
- function ReplyItem({
- comment,
- isOwner,
- onDelete,
- }: {
- comment: Comment;
- isOwner: boolean;
- onDelete: (id: string) => void;
- }) {
- return (
- <div className="flex gap-2.5 animate-fade-in">
- <Avatar name={comment.user?.name ?? 'U'} size="sm" />
- <div className="flex-1 min-w-0">
- <div className="flex items-center gap-2 mb-0.5">
- <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>
- {comment.user?.name ?? 'Unknown'}
- </span>
- <span className="text-xs ml-auto" style={{ color: 'var(--text-subtle)' }}>
- {new Date(comment.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
- </span>
- </div>
- <p className="text-sm leading-relaxed" style={{ color: 'var(--text-muted)' }}>
- {comment.content}
- </p>
- {isOwner && (
- <button
- onClick={() => onDelete(comment.id)}
- className="text-xs mt-1 transition-colors"
- style={{ color: 'var(--text-subtle)' }}
- title="Delete reply"
- >
- Delete
- </button>
- )}
- </div>
- </div>
- );
- }
|